查看原文
其他

58 同城 App 性能治理实践-iOS 启动时间优化

廖露阳 58技术 2022-03-15

导读

启动速度是用户体验一款 APP 的第一印象,良好的启动速度对于提升用户体验有着积极的作用。58 同城 APP 作为一款承载招聘、安居客、黄页、二手车等各大业务线的平台型 APP,复杂的业务启动逻辑与众多 SDK 初始化逻辑对 58 同城的启动治理带来了不少挑战。


挑战与治理思路

启动治理是一项长期且需要公司全业务线参与的课题,当前58同城在启动治理上主要面临以下一些挑战:
  1. 如何准确、稳定地衡量 58 同城 APP 的启动时间,以及如何横向比较 58 同城 APP 在业界主流 APP 中的启动时间?
  2. APP 启动变慢了,如何快速找出并定位是哪些耗时方法导致的启动速度降低?
  3. 在某个版本进行了启动优化,下个版本的启动耗时又突然爆发式增长,如何监控各个版本的启动耗时数据,及早的介入新增版本的启动耗时优化?
为了解决上面的问题,58 同城形成了一套系统的启动治理思路,首先通过探索自研了一套启动时间统计工具,包括统计单个 APP 启动耗时和多个 APP 之间的横向启动数据比较,解决启动时间衡量的问题。
然后,开发一套方法耗时检测工具,用于检测启动过程中的方法耗时数据,方便及时定位跟踪启动变慢原因,并基于方法耗时检测工具,监控各个版本新增的方法耗时数据,在新版本发版前及早介入启动优化工作。
基于分析方法耗时检测工具生成的启动数据,我们针对性的优化了启动逻辑、调研并实践二进制重排方案,通过动态库懒加载方案来优化 pre-main 阶段。因此,本文将从启动时间统计工具、启动方法耗时检测工具、二进制重排、动态库懒加载几个方向介绍 58 同城 APP 在启动优化上的实践。下图展示了58 同城 APP 启动优化的治理思路:

启动时间衡量

1、启动时间的定义

一般而言,我们把 APP 启动时间定义为,从点击图标到用户看到第一个界面的时间,期间主要包含了两个阶段:
  • pre-main 阶段,从点击图标到 main 函数执行前:
  1. 动态库加载,包含系统动态库及自定义动态库;
  2. Rebase,修正当前镜像内部的指针偏移;
  3. Bind,修正不同镜像之间的外部指针偏移;
  4. Objc 初始化,包括 Objc 类、Category 的注册,以及 selector 的唯一性检查;
  5. Initializer 初始化,每个类和 Category 的 load 方法执行、C/C++ 构造函数调用、非基本类型的 C++ 静态全局变量初始化;
  • post-main 阶段,main 函数执行到首屏展现:主要执行各种 SDK 注册、各种业务初始化以及准备首屏渲染需要的数据等逻辑;

2、如何测量启动时间?

一般来说,单个 APP 的启动时间测量可以通过下面两种方案获取:
  • 直接通过 Xcode 自带的 Timer Profier 工具进行测量,在xcode11 之后 Instrument 提供了 App Launch 工具,可以看到 pre-main 阶段的各个过程的耗时;
  • 分别统计 pre-main 阶段和 post-main 阶段,其中 pre-main 阶段通过设置 Xcode 运行环境来获取(Project→Scheme→Edit Scheme…,在 Environment Variables 中添加 DYLD_PRINT_STATISTICS=1 的环境变量),post-main 阶段可以通过手动埋点的方式来获取;

启动时间统计工具

上面两种方式都需人工进行干预,没法进行自动化统计,58 同城自研了一种基于读取手机系统日志来获取 APP 启动时间的自动化工具,通过这个自动化统计工具,我们不仅可以实现单个 APP 启动时间的获取,还可以实现多个 APP 之间的横向启动数据比较。
我们发现,iOS13.0 以后,在隐私-分析与改进-分析数据中有以log-power-xxx.session命名的日志文件,日志文件中提供了应用运行的一些基本数据信息,系统日志的基本格式如下:
{ "log_timestamp" : "", "init_count" : 1, "machine_config" : "iPhone12,3", "metrics" : [ { "app_sessionreporter_key" : "", "app_build_version" : "", "app_version" : "10.10.0", "app_adamid" : 0, "app_arch" : "", "app_bundleid" : "", "performance_metrics" : { "memory" : { "average" : 1000, "peak" : 1000 }, "app_performance" : { "launch" : { "count" : 2, "sessions" : [ 1250, 1500 ] } } }, "app_is_clip" : 0 } ]}
其中,app_bundleid 表示启动应用的 bundleid,app_performance下的 launch 信息中就是关于启动时间的数据,count 表示当天启动该应用的次数,sessions 分别提供了每次启动的耗时(从点击图标到首屏渲染时的耗时)。基于上面的信息,我们可以获取到该 app_bundleid 对应的启动耗时数据,完整分析该日志文件,则可以获取到当日所有启动过的 APP 耗时数据。
为了能够自动化统计启动数据,我们通过一个三方框架来自动启动指定 APP,在生成系统日志后,自动分析该日志文件,输出各个 APP 的启动时间。为了保证启动数据的稳定,减少实验结果偏差,苹果官方推荐我们每次测试时进行如下操作:
重启手机;
点击其他应用,尽量将该应用在 APP 内的缓存给替换掉;
运行多次,去掉偏差较大的值,取平均值;
基于这个原则,我们在每次跑数据时。通过脚本重启 SpringBoot,延迟20s 后再去打开一个 APP。通过启动时间自动化统计工具获取的不同 APP 横向启动数据如下:

获取线上用户启动耗时

上面几种方式,都算是一种线下单机版的启动耗时测量方案,对于线上实际用户的启动耗时数据获取,可以通过下面几种方式进行:
  • 通过 Xcode 自带工具来查看,选择Xcode—>Window—>Organizer,在左侧菜单栏选择 Launch Time 项查看线上用户 APP 的启动耗时数据,这种方式主要看线上用户整体启动耗时区间分布情况;
  • 通过获取进程信息,拿到进程创建时间作为启动初始点,如下代码。
    这种方式经过实际测试发现,获取到的进程创建时间偏差较大。
    /**获取进程创建时间*/+ (NSTimeInterval)processStartTime { struct kinfo_proc kinfo; if ([self processInfoForPID:[[NSProcessInfo processInfo] processIdentifier] procInfo:&kinfo]) { return kinfo.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 + kinfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0; } else { return 0; }}+ (BOOL)processInfoForPID:(int)pid procInfo:(struct kinfo_proc*)procInfo { int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid}; size_t size = sizeof(*procInfo); return sysctl(cmd, sizeof(cmd)/sizeof(*cmd), procInfo, &size, NULL, 0) == 0;}
  • 创建一个自定义动态库(或直接使用已有的自定义动态库),在 +load 方法中进行埋点作为 APP 的启动时间,为了尽可能将其他动态库中的耗时统计到,我们可以将自定义的动态库放在所有动态库加载的第一位。
那如何将指定的动态库放到第一位去加载呢?Cocoapds 管理的项目在 pod install的过程中会将动态库按一定的顺序进行排序,那这个排序顺序应该就要在 Cocoapods 生成的配置文件中体现出来,查看Pods-58tongcheng.debug.xcconfig文件,我们发现在OTHER_LDFLAGS配置下果然有已经排好序的各个待加载动态库,如下图。
如果我们需要将我们自定义的动态库放在第一位加载,只需要将其按照-framework“xxx”的格式写到第一位即可,如在将 LoadHook 这个动态库按照-framework“LoadHook”这种格式写在第一位之后,查看编译生成的 Mach-O 文件的 Load Commands 区,可以看到 LoadHook 的确是被编排到第一位加载了。


启动治理实践

为了能够快速定位启动过程中耗时较长的方法,我们调研并实现了一套方法耗时检测工具,耗时检测工具包括pre-main阶段和post-main阶段的启动方法。

1、启动耗时方法检测工具

主线程中方法执行的快慢会直接影响 APP 启动的速度,因此,找出启动过程中那些耗时的方法,对那些耗时较长的方法进行优化可以显著改善启动时间。对于pre-main阶段主要是统计 Initialize 阶段的+load方法、C/C++ 构造函数调用和非基本类型的 C++ 静态全局变量初始化的耗时,post-main阶段基本都是我们编写的业务逻辑。
对于C/C++ 构造函数和非基本类型的 C++ 静态全局变量会存储在 Mach-O __DATA 下的__mod_init_func的section中,而 load 方法的执行是早于C/C++ 构造函数和非基本类型的 C++ 静态全局变量的,因此,我们可以在 load 中读取__mod_init_func这个 section ,拿到__mod_init_func中每个函数的原地址并保存到一个队列中,然后将原函数指向为 hook 后的函数地址,这样我们在 hook 后的函数中从队列中取出保存的所有原函数地址并执行,从而获取到所有C/C++ 构造函数和非基本类型的 C++ 静态全局变量的执行时间。
58 同城 APP 在C/C++ 构造函数和非基本类型的 C++ 静态全局变量的使用上并不多,总体耗时数据也较少,因此对于 pre-main 阶段我们主要关注的是 load 方法的监控与优化。

1.1、+load 方法耗时检测

1.1.1、+load 耗时统计的技术方案
我们知道,在类和分类中都可以定义 +load 方法,因此,为了完整统计所有 +load 方法耗时,我们的方案需要考虑到分类中存在 +load 方法的情况。
对于定义了 +load 方法的类和分类,在编译时会被分别写入到 Mach-O 的 __DATA 段的__objc_nlclslist和__objc_nlcatlist两个 section。
因此,我们的方案是,首先通过读取 Mach-O 中 __DATA 段的__objc_nlclslist和__objc_nlcatlist两个 section,拿到包含 +load 方法的类和分类。
/** 读取含load方法的分类__objc_nlcatlist section,读__objc_nlclslist section类似 */- (void)readLoadCategoryListSection:(const uint64_t)mach_header{ const struct section_64 *non_lazy_nlcatlist_section = getsectbynamefromheader_64((void *)mach_header, "__DATA", "__objc_nlcatlist"); if (non_lazy_nlcatlist_section == NULL) { return; } for (uint64_t offset = non_lazy_nlcatlist_section->offset; offset < non_lazy_nlcatlist_section->offset + non_lazy_nlcatlist_section->size; offset += sizeof(const void **)) { struct category_t *cat_ref = *(struct category_t **)(mach_header + offset); Class cls = cat_ref->cls; if (cls == NULL) { continue; } [self.loadClassArray addObject:cls];
NSMutableArray *categoryArray = self.classKeyCategoryValueMap[cls]; if (!categoryArray) { categoryArray = [NSMutableArray array]; self.classKeyCategoryValueMap[(id<NSCopying>)cls] = categoryArray; } [categoryArray addObject:@((uintptr_t)cat_ref)]; }}
Mach-O 文件解析可以参考 58 开源项目:WBBlades
拿到含有 +load 方法的类和分类之后进行遍历,拿到原始 +load 方法的 IMP,通过imp_implementationWithBlock的方式 hook 掉原有 +load 方法的 IMP,在新的 IMP 中,在方法开始和结束位置插入时间统计代码,中间去调用原有 +load 逻辑。
IMP originLoadIMP = origin_load_method->imp;IMP hookLoadIMP = imp_implementationWithBlock(^(__unsafe_unretained id self, SEL cmd){ uint64_t starttime = currentTime(); ((void (*)(id, SEL))originLoadIMP)(self, cmd); uint64_t endtime = currentTime(); recordLoadTrace(array, invokeMethodName, endtime - starttime); });origin_load_method->imp = hookLoadIMP;
这样,通过前、后相减得到每个 +load 方法耗时数据,并将所有数据导出到 excel 文件中,并写入到沙盒文件。
1.1.2、遇到的一个坑
在读取 Mach-O 中__objc_nlclslist节的时候,发现读出了大量__ARCLite__类型的 load 方法,__ARCLite__load是系统库加载的时候调用的,为了避免这个干扰项,我们在读取的过程中需要跳过__ARCLite__load的处理。
1.1.3、load 耗时优化
通过上面方案获取所有类和分类的 load 方法耗时之后,就可以进行针对性优化了。优化的手段一般有下面几种:
  1. 如果时机可以的话,优先使用 +initialize 方法替换 load 方法;
  2. 继续使用 load 方法,但是通过监听启动完成后的一个通知,再执行原来的一些耗时逻辑,从而将耗时逻辑尽可能的延后;
  3. 另一种方案就是利用 Clang 提供的编译器函数实现对 Mach-O 的写能力,通过使用__attribute__((used, section("__DATA,__wbce_func")))来标记函数,在编译期时,编译器会将标记的数据写入到指定的 __DATA 段的__wbce_func section中,在运行时,通过读取 Mach-O 的 __wbce_func 节,取到保存的函数地址并执行。
这种方式可以快速替换 load 方法,并将原 load 方法中的逻辑移入到启动过程中的某一个合适的时机去执行。
实际上,获取到所有 load 数据之后,我们发现,正常一个普通 load 方法是不耗时的,一个耗时的 load 方法主要是在里面进行了 Method Swizzling、数据存储等耗时操作.
因此,我们优化的主要目标可以集中在那些耗时 load 上,利用 load 耗时检测工具,我们推动各业务线优化了多个 load 方法的处理,累计优化约182ms(iPhone 6S,iOS 13.0 测试设备),同时做好每个版本 load 的数据监控,防止新版本出现耗时较大的 load 方法。

1.2、OC 方法耗时检测

1.2.1、OC 方法耗时检测方案
OC 是一种动态语言,方法的调用会传递对象本身和对象的方法名称两个隐藏参数,运行时方法的调用过程会交给一个 C 函数 objc_msgSend 来完成,objc_msgSend 会根据传入的对象和方法的 selector 去查找对应的函数指针并执行。整个基本流程如下:
可以看到,OC 方法的执行必然会经过 objc_msgSend,因此如果我们能 hook 掉 objc_msgSned 方法,也就能拦截到所有 OC 方法的执行过程,这样我们在原方法的前后插入时间统计代码就能够计算出原方法的执行时间了。
而 objc_msgSend 是一个 C 方法,因此我们可以用 fishhook 进行 hook,苹果公司为了优化 objc_msgSend 的调用性能,对 objc_msgSend 使用了汇编进行编码,因此 hook 后的 objc_msgSend 方法也需要基于汇编进行处理。
由于 objc_msgSend 是变参函数,因此在 hook 后的汇编函数中,先要保存寄存器中的数据来保护现场,并插入我们自定义的打点方法,用于记录函数执行的开始时间,同时保存 LR 寄存器中的数据。然后恢复寄存器的数据,开始原始 objc_msgSend 方法的调用,执行完原始 objc_msgSend 方法的调用后,再插入我们自定义的打点方法,用于记录函数执行完成时间,通过前、后时间相差得到原始函数的执行时间。
为了能够保存函数调用记录,我们在结束原方法调用并插入结束时间打点后,就需要对该方法的调用生成一条调用记录保存下来,同时放到一条调用队列中,主要的数据结构如下:
typedef struct { __unsafe_unretained Class cls; SEL sel; uint64_t time; int depth;} LTCallRecord;//一条方法调用记录
typedef struct { LTCallRecord *record; int allocated_length; int index;} LTMainThreadCallRecord;//方法调用队列
通过 hook objc_msgSend 拿到各个 OC 方法的调用时间数据之后,根据函数调用栈为先进后出、而同一层的函数为先进先出的特性,设计相应数据结构以及记录调用深度,来将整个调用过程还原为函数调用栈的形式。同时,基于最外层方法耗时进行排序,以函数调用栈的格式输出到 Excel 中,输出结果如下:
1.2.2、基于 OC 方法耗时检测的优化效果
基于 OC 方法耗时检测工具,我们检测出UserAgent 获取、音频预加载、WIFILog、IWatch 链接等几个耗时处理逻辑,分别通过异步处理、延迟加载等方式进行了优化,对于涉及到业务线部分,推动各业务线进行了相应优化,累计减少约450ms(iPhone 6S iOS 13.0 测试设备)。

1.3、基于方法耗时检测工具在版本监控上的应用

方法耗时检测工具包含 pre-main 阶段的 load 耗时检测和整个 post-main 阶段方法检测,然后输出对应的耗时数据报表,从而找到那些耗时方法。另外,为了解决每个版本可能新增较大耗时方法的痛点,检测工具还应用在了各版本启动耗时监控上,我们在每个版本集成之后,跑一次最新版本的启动耗时数据,与上一版本进行对比,及时监控每个版本新增的启动耗时方法。
每个版本在方法耗时检测工具的监控下,我们能够及时发现和优化新增的耗时方法。为了进一步优化启动耗时,我们调研并实践了二进制重排方案,以及为优化 pre-main 阶段我们实现了动态库懒加载方案。

2、二进制重排实践

二进制重排的关键是获取启动过程中的符号,目前业界常用方案有:
  • 基于静态扫描+运行时 trace 的方案来获取启动时的符号,从而生成 order file 文件实现二进制重排;
  • 基于 Clang 静态插桩的方式来获取启动过程中的所有函数符号;
第一种方案存在对于initialize、block、以及 C++ 函数hook 不到的问题,第二种基于 Clang 静态插桩的方案则可以解决前种方案的不足获取到所有符号。因此,58 同城选择了基于 Clang 静态插桩的方案来获取启动符号.

2.1、虚拟内存与 Page Fault

早期计算机中,并没有设计虚拟内存,程序都是直接从磁盘按序完整地加载进物理内存中,这种方式由于使用的是真实物理内存地址且程序是有序加载进去的,那么通过计算地址偏移就可以访问到其他程序的内存,存在安全隐患,另外由于是完整加载,而用户实际使用时只会用到少部分功能,这样也会造成内存的极大浪费。
为了解决这些问题,现在的操作系统在物理内存的基础上引入了虚拟内存的概念。虚拟内存引入后,每个进程可以认为自己拥有从0x000000~0xffffff这一大片连续的内存空间,只不过这个内存地址是虚拟的,要访问实际物理内存地址,需要通过操作系统维护的一张映射表映射之后才可以真正访问到,而映射表是以页(Page)为单位进行管理的。

当进程要访问的一个虚拟内存页在经过映射表映射之后发现对应的物理内存页不存在时,会触发一次缺页中断Page Fault,此时会发生 I/O 操作,将磁盘中的数据读入到物理内存页中,读取的过程中苹果还会对读入的内存页进行验签处理,因此如果频繁发生Page Fault的话,Page Fault产生的耗时也不可小觑。Page Fault的数量可以通过 Instruments 自带的 System Trace 工具来查看,其中File Backed Page In就是Page Fault的次数。

2.2、二进制重排优化原理

APP 启动过程中,会加载大量的类、执行大量的方法,当频繁触发Page Fault的话,对启动耗时会产生不小的影响,因此尽可能减少Page Fault的数量可以优化启动耗时。
当启动过程中需要调用的两个方法method1和method2分布在不同的内存页时,此时操作系统需要触发两次缺页中断Page Fault,来加载这两页到内存,如果通过一定的技术手段将这两个方法排列在同一个内存页中,那此时系统只需要触发一次缺页中断即可,如果能够减少一定数量的缺页中断次数,那也就能够减少整体启动耗时。
因此,二进制重排的一个核心问题就是如何将不同的方法尽可能地排列在同一个内存页中。
生成一个二进制的 Mach-O 文件,需要经过编译、链接的过程,Xcode 使用 ld 作为链接器,ld 链接器的配置中有一个名为Order File的参数,它可以配置一个 order 文件路径。
一个 order 文件内存储的是符号列表,当我们配置了 order 文件之后,ld 在工作的时候就会根据 order 文件中的符号按照顺序进行排列生成二进制文件。
因此,如果我们将启动过程中调用的函数符号都找到,并配置到 order 文件中,那生成的二进制文件在启动时所调用的方法都会尽量排在相同且相邻的内存页上,从而减少启动过程中发生Page Fault的次数,减少因Page Fault而产生的耗时。
因此,现在的关键是找到启动过程中调用的函数符号。通过 hook objc_msgSend 能够拿到 OC 方法的调用,但是对于load方法、C++ 构造函数还需要通过扫描 Mach-O 文件来获取,还有一种方案是基于 Clang 编译期插桩来获取符号,Clang 插桩可以一次获取 OC、Swift、C、block 函数符号,因此,58 APP 采用的就是基于 Clang 插桩来实现符号收集。

2.3、Clang 插桩收集启动过程中的函数符号

基于 Clang 插桩获取符号有两种实现方式:
  • 一种是自己编写一个 Clang 插件,在 Clang 插件中我们去分析抽象语法树不同的节点,在相应的节点中插入自定义的代码用于符号收集,这种自定义 Clang 插件的方式优点是可根据自己需求进行灵活处理,缺点是通用性较差,
  • 一种是利用 SanitizerCoverage 工具进行符号收集。
SanitizerCoverage 是 LLVM 内置的一个代码覆盖率检测工具,在编译时,它能够根据我们的编译配置,将一系列以__sanitizer_cov_trace_pc_为前缀的函数插入到我们自定义的函数内,比如,我们在Clang的自定义配置 Other C Flags中新增-fsanitize-coverage=trace-pc-guard标志时,编译器将会为每个自定义的函数中插入__sanitizer_cov_trace_pc_guard回调函数。
Clang 静态插桩收集符号的原理就是,利用编译期在每一个函数内部插入回调函数__sanitizer_cov_trace_pc_guard,我们通过实现该函数,在运行期间就能够拿到被插入该函数的原函数地址,通过函数地址解析出函数符号,从而达到收集启动过程中函数符号的目的。
因此,为了 Clang 前端能够利用 SanitizerCoverage 插入插桩函数,我们首先需要在Other C Flags 中添加-fsanitize-coverage=trace-pc-guard配置,这样在编译后,我们的自定义函数中都会被插入__sanitizer_cov_trace_pc_guard函数,然后我们需要实现该回调函数,并在回调函数内部收集原函数符号:
//插桩的初始化方法,首次会进入到这里面void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop) { static uint64_t N; if (start == stop || *start) return; for (uint32_t *x = start; x < stop; x++) *x = ++N; }//每个原函数内部被插入的回调方法void __sanitizer_cov_trace_pc_guard(uint32_t *guard) { if (!*guard) return; }
函数__sanitizer_cov_trace_pc_guard是在编译期由 Clang 插入到原函数内部的,因此__sanitizer_cov_trace_pc_guard函数算是原函数内部的一个嵌套子函数,而操作系统在执行 bl 跳转指令的时候,会先保存下一条指令地址到lr寄存器中,当__sanitizer_cov_trace_pc_guard函数执行完即执行ret指令后,需要继续回到原函数中继续执行,操作系统会去读取 LR 寄存器中的值拿到原函数的下一条待执行指令地址,这个地址可以通过下面代码来获取:
void *PC = __builtin_return_address(0);
也就是说,在__sanitizer_cov_trace_pc_guard函数中我们可以通过 __builtin_return_address(0) 拿到原函数某条指令的地址,那我们只要再通过 dladdr() 函数就可以获取到原函数的信息,从而拿到该函数符号。
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) { if (!*guard) return;
void *PC = __builtin_return_address(0); Dl_info info; dladdr(PC, &info);
printf("fname=%s \nfbase=%p \nsname=%s\nsaddr=%p \n",info.dli_fname,info.dli_fbase,info.dli_sname,info.dli_saddr);
char PcDescr[1024]; printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);}
在实际的使用过程中,需要解决以下几个主要问题:
  1. 多线程问题,由于__sanitizer_cov_trace_pc_guard函数是各个方法内插入的回调函数,而原函数可能处于不同的线程中,从而造成__sanitizer_cov_trace_pc_guard函数调用的多线程问题,解决这个问题可以使用原子队列 OSAtomicEnqueue 来处理,使用原子队列之后需要在 Other C Flags 配置中修改原来的配置为如下形式:
-fsanitize-coverage=func,trace-pc-guard
  1. 如果要支持 Swift 符号收集,由于 Swift 的编译前端与 OC 不同,需要在编译配置的Other Swift Flags下,新增下面配置:
-sanitize-coverage=func
-sanitize=undefined
  1. 使用 Cocoapods 管理的项目,存在多 target 的情况下,需要在每个 target 下都要进行上面的Other C Flags配置。
收集到启动过程中的函数符号之后,将这些符号写入到 order 文件中,并将该 order 文件的地址在 Xcode 的Order File参数下进行配置即可。

2.4、二进制重排前后的效果对比

尽量完全冷启动 APP 进行多次试验,二进制重排前、后对比的缺页中断次数如下:

可以看到,二进制重排优化后,缺页中断的次数减少约1626次,耗时减少约162ms(iPhone 6S测试设备)。

3、动态库懒加载

我们知道,pre-main 过程中,有dylib的加载步骤,而动态库加载是需要耗时的,苹果建议我们自定义的动态库不要超过 6 个,因此,尽量减少启动过程中的动态库加载有助于启动耗时的优化。减少启动过程中的动态库加载主要有以下两个方案:
  • 一个是动态库转静态库;
  • 一个是多个动态库进行合并;
上面两种方案都可行,但是在实际工程操作中可能存在转换繁琐,需要解决部分依赖的问题。iOS 58 同城 APP 采用了一种动态库懒加载的方案,来减少启动过程中需要加载的动态库数量,从而达到优化启动耗时目的。

3.1、动态库懒加载方案

所谓动态库懒加载是指,在启动的过程中并不加载该动态库,而是在业务真正使用到该动态库中的内容时才进行加载,从而减少启动耗时。在 Cocoapods 1.2 之前存在配置动态库懒加载的入口,升级到 1.8 之后没有了动态库懒加载的配置入口,我们需要在pod install之后生成的配置文件中进行配置。
使用 Cocoapods 管理的项目,在pod install之后,会生成Pods-xxx-frameworks.sh和Pods-xxx.adhoc/debug/release.xcconfig这两个文件,其中Pods-xxx-frameworks.sh文件脚本负责架构剔除和重签名等功能,而Pods-xxx.adhoc/debug/release.xcconfig文件则负责静态库和动态库的链接配置,我们自定义的动态库想要进行懒加载,只需要修改xxx.xcconfig配置文件,将需要懒加载的动态库从配置文件中移除,这样保证懒加载的动态库参与签名和拷贝,但是不参与链接。

3.2、动态库懒加载后的调用方式

由于采用动态库懒加载后动态库在编译时没有参与链接,原有的代码调用方式会报找不到对应动态库符号的错误,因此,原有动态库的调用方式需要修改成Runtime动态调用的形式,在使用某个动态库中的类时,先动态获取该类,如果获取不到,则通过dlopen的方式动态加载该动态库:
dlopen([path UTF8String], RTLD_LAZY);
动态库懒加载后,将减少启动过程中dlopen带来的损耗,同时减少 rebase/bind 的时间,以及避免了该懒加载动态库内 load、contructor 等函数在启动过程中执行。

3.3、有益效果

目前,58 同城已经有 12 个动态库 采用了懒加载的方式引入,采用懒加载后在启动速度方面减少了约 817ms(iPhone 6P iOS 12.0),同时,后续将会逐步将更多的静态库转成动态库懒加载的方式进行接入。

总结与展望

本文首先介绍了 58 同城基于手机系统日志获取启动时间方案自研的横向启动时间对比统计工具,然后介绍了方法耗时检测工具、二进制重排和动态库懒加载三个方向在 58 同城 APP 启动优化上的一些实践经验,方法耗时检测工具已应用在持续监控每个版本的启动耗时数据上,同时方法耗时检测工具已应用在 58 同镇本地版 APP 上。下一阶段,我们将持续优化耗时检测工具并重点关注 pre-main 阶段的优化。

参考文献:

[1] Clang 12 documentation:https://clang.llvm.org/docs/SanitizerCoverage.html
[2] WBBlades:基于Mach-O文件解析的APP分析工具:https://mp.weixin.qq.com/s/HWJArO5y9G20jb2pqaAQWQ
[3] 基于二进制文件重排的解决方案 APP启动速度提升超15%:https://mp.weixin.qq.com/s/Drmmx5JtjG3UtTFksL6Q8Q
[4]App 启动速度怎么做优化与监控?:https://time.geekbang.org/column/article/85331
[5] 监控所有的OC方法耗时:https://juejin.cn/post/6844903875804135431


作者简介:
廖露阳,58 同城 – 平台技术部 – iOS 技术部 高级研发工程师
朴惠姝,58 同城 – 平台技术部 – iOS 技术部 高级研发工程师
邓竹立,58 同城 – 平台技术部 - iOS 技术部 资深研发工程师


推荐阅读:

58安全-证件识别之版面分析实践

58安全-违规水印检测的技术实践

Taro 3.2 适配 React Native 之样式内幕

Taro3.2 适配 React Native 之运行时架构详解


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存